UserScript から文字を挿入する方法
Cosense における文字入力の仕組み
https://scrapbox.io/assets/index.js を読むと以下のような仕組みになっていることが分かる。該当箇所はCursor.setPosition, Cursor.focus(), inputTypeで頑張ってgrepすると見つけられる。Chrome devtools で break point を仕掛けながら挙動を観察すると、より理解が深まるはず。 ユーザがエディタをクリック or タップすると...
.pointer-eventにmousedown/mouseupイベントが dispatch される
Cosense の内部コードがそれをキャッチして、カーソルを表示する処理を呼ぶ
カーソルの位置はMouseEventのclientX, clientY, pageX, pageYをもとに計算
カーソルが表示されると、1描画フレーム待機してからtextarea#text-inputに.focus()される
#text-inputにフォーカスが合った状態でユーザが文字を入力すると...
#text-inputのvalueプロパティに文字が挿入された上で、inputイベントが dispatch される
Cosense の内部コードがそれをキャッチして、エディタに文字を入力する処理を呼ぶ
InputEventのinputTypeプロパティや#text-inputのvalueプロパティを見て入力する文字が決められる
UserScript から文字を挿入する方法
要は上記のフローで発生するイベントを全てエミュレートすれば良い。つまり以下のようなことをやる。
code:js
/**
* 指定した位置にカーソルをフォーカスする。
* @param x フォーカスする位置の x 座標 (layout viewport の左端からの距離)
* @param y フォーカスする位置の y 座標 (layout viewport の上端からの距離)
*/
function focusEditor(pointerEvent, x, y) {
const eventInitDict = {
bubbles: true,
cancelable: true,
button: 0,
// clientX, clientY は仕様上は viewport の左上からの相対座標とされているが、
// 実際には、layout viewport の左上からの相対座標にスクロール量を加算したものである。
// そこで、スクロール量を加算して clientX, clientY を計算する。
clientX: window.scrollX + x,
clientY: window.scrollY + y,
};
// mousedown イベントだけだと 範囲選択モードになってしまうため、mouseup イベントも dispatch する
pointerEvent.dispatchEvent(new MouseEvent('mousedown', eventInitDict));
pointerEvent.dispatchEvent(new MouseEvent('mouseup', eventInitDict));
}
// .pointer-eventにmousedown/mouseupイベントを dispatch
const pointerEvent = document.querySelector('.pointer-event');
focusEditor(pointerEvent, 80, 400);
await new Promise(requestAnimationFrame);
// #text-inputのvalueプロパティに文字をセットしつつ、inputイベントを dispatch
const textInput = document.querySelector('#text-input');
textInput.value = text;
const event = new InputEvent('input', {
bubbles: true,
cancelable: false,
inputType: 'insertText',
data: 'Hello World!',
});
textInput.dispatchEvent(event);
N行目M列目に文字を挿入したい場合
.pointer-eventにMouseEventを dispatch するには、clientX/clientY が必要になる。そのため、N行目M列目に文字を挿入したい場合は、その位置の clientX/clientY を計算しないといけない。
エディタ内の文字の要素には.char-indexというクラスが割り振られているので、基本はその要素の viewport からの相対位置を取得したら良い。
code:js
// 今回文字を挿入したい位置。0-based のインデックスで、2行目3列目を示してる。
const targetPosition = { line: 1, column: 2 };
const lines = Array.from(document.querySelectorAll('.lines .line'));
const chars = Array.from(targetLine.querySelectorAll('.char-index'));
const targetCharRect = targetChar.getBoundingClientRect();
focusEditor(pointerEvent, targetCharRect.left, targetCharRect.top + targetCharRect.height / 2);
await new Promise(requestAnimationFrame);
一見良さそうに見えるが、この実装には欠陥がある。Cosense では行にカーソルがある時は生のテキストを表示して、カーソルがない時は[https://.../img.png]などを画像として表示する仕様があるため。行にカーソルが無い時は、そもそも画像を構成する文字にカーソルを合わせられない問題がある。
この問題を回避するためにはまず行の適当な位置にカーソルを置き、その後改めて目的の位置にフォーカスする必要がある。
code:js
const targetPosition = { line: 1, column: 2 };
const lines = Array.from(document.querySelectorAll('.lines .line'));
const targetLineRect = targetLine.getBoundingClientRect();
// 目的の位置にフォーカスする前にその行の末尾にフォーカスして、行全体をテキスト化させる
focusEditor(pointerEvent, targetLineRect.right - 1, targetLineRect.top + targetLineRect.height / 2);
const chars = Array.from(targetLine.querySelectorAll('.char-index'));
const targetCharRect = targetChar.getBoundingClientRect();
// 改めて目的の位置にフォーカスする
focusEditor(pointerEvent, targetCharRect.left, targetCharRect.top + targetCharRect.height / 2);
await new Promise(requestAnimationFrame);
考慮すべきエッジケース
こういうハック的なことを UserScript に取り入れる時は色々考慮すべきことがあるので、それを頭に入れた上で UserScript を実装するとよい。
画像がある行でも動作するか
1文字もない行でも動作するか
行の末尾でも動作するか
タイトル行 (インデックスが0の行) でも動作するか
[/icons/hr.icon]のように幅いっぱいに広がる画像がある行でも動作するか
文字が多すぎて折り返しが発生してる行でも動作するか
長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い行
エディタの幅をはみ出している行でも動作するか
table:表
長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長いカラム 長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長い長いカラム
a b
文字幅の大きい文字(例: あ)や文字幅の小さい文字(例: i)が混在していても、目的の位置に文字を挿入できるか
共同編集によってページの先頭に行が追加されて文字挿入する予定だった行のインデックスが変わった時、どうするか
共同編集によって文字挿入する予定だった行が削除された時、どうするか